przn 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b33677433cd4802ee75ef9d7eb821d2dbb6faf8b1d2364d535b6aa28bffa6eb3
4
- data.tar.gz: 930ecad39096eea771759134cd6c7e4a83c55a00ae98c59ead5afea1dcc46f17
3
+ metadata.gz: 926f4301117fde5642b907fbf96340d1e547af78086eb1fd47ed45fa16472e5e
4
+ data.tar.gz: 8cbbf68f1d784b6c6ed2ce7ba600c0a214dc8a688a785b5044701b9e8c587b39
5
5
  SHA512:
6
- metadata.gz: 08eafdea3c2f55a29ccfd879b3bfa1541576be74fbb042cc33c527e39289e3dcf1375a10df033820e37f69e9f6726f580cd19ae468d633d73c9d65a23736674b
7
- data.tar.gz: fcbf194e2a090de86a823fd8a0076977db73cf69f857cea6f4406f9fe31c2f44dab60120d61dbda7f10545e2fd750089df8e34233f0808fefd096bcec3cc25e1
6
+ metadata.gz: 6f6a58582c7a21ccfab9750ee0ebc39525626af2233d503497e933a1d5b108bcf08b34c156115730c145e3c4dd7473b8d48633597d68b487bcc5702e29132183
7
+ data.tar.gz: 0a3bf226c4c4e6300d515cf50274c1ad266556c9a984df6090ed9dec1dbcc7cc3e878675131e2d26f0e71b1167046f1d5ea3e7f1b0f965101f942e5a728d9967
data/README.md CHANGED
@@ -23,6 +23,23 @@ przn your_slides.md @42
23
23
 
24
24
  Out-of-range numbers are clamped to the last slide, so `@9999` jumps to the end.
25
25
 
26
+ ### Extended-display presenter mode
27
+
28
+ ```
29
+ przn --present your_slides.md
30
+ ```
31
+
32
+ On a setup with a secondary display (projector / external monitor) and running inside [Echoes](https://github.com/amatsuda/echoes), `--present` auto-spawns an **audience window** on the second display showing the clean current slide, while the laptop pane becomes the **presenter view**:
33
+
34
+ - Current slide rendered as normal
35
+ - Speaker notes (`{::note}` / `<note>` markup) shown in a side strip — stripped from the audience view
36
+ - Next slide's title hint
37
+ - Elapsed-time clock (or, when `rabbit:` is themed, the runner-bar visualization)
38
+
39
+ If only one display is attached or Echoes isn't the host terminal, `--present` falls back to today's mirror mode with a one-line warning on stderr.
40
+
41
+ Implementation: the two `przn` processes coordinate over a Unix socket. The presenter forwards every slide navigation as a `goto` message; the audience renders and otherwise stays silent. Notes are not transmitted to the audience side.
42
+
26
43
  ### PDF export
27
44
 
28
45
  Two flavors:
@@ -58,6 +75,8 @@ przn --export prawn -o output.pdf your_slides.md
58
75
 
59
76
  przn's Markdown format is compatible with [Rabbit](https://rabbit-shocker.org/)'s Markdown mode.
60
77
 
78
+ > **HTML-ish tag attributes** — every `<tag attr=value>` block below (`<bg>`, `<at>`, `<img>`, `<font>`) accepts three value forms: double-quoted `attr="value"`, single-quoted `attr='value'`, and unquoted `attr=value` (HTML5-ish — anything that isn't whitespace, `=`, `<`, `>`, a quote, or backtick). Self-closing tags need a space before `/>` when the last attribute is unquoted (`<img src=foo.png />`).
79
+
61
80
  ### Slide splitting
62
81
 
63
82
  Slides are separated by `#` (h1) headings.
@@ -218,6 +237,49 @@ content...
218
237
 
219
238
  The previous slide's background is cleared on every navigation, and on `przn` exit, so your shell isn't left tinted.
220
239
 
240
+ ### Absolute-position text
241
+
242
+ Place text at an arbitrary `(column, row)` on the slide, escaping the normal top-down paragraph flow:
243
+
244
+ ```markdown
245
+ # Layout test
246
+
247
+ <at x="10" y="5">top-left ish</at>
248
+ <at x="40" y="15"><size=3>BIG</size></at>
249
+ <at x="80" y="25"><color=red>warn</color></at>
250
+ <at x="50%" y="50%">dead center</at>
251
+
252
+ {::at x="10" y="20"}same thing, kramdown form{:/at}
253
+ ```
254
+
255
+ - `x` / `y` accept two forms:
256
+ - **Plain integer** — 1-based terminal cells, matching the cursor-position escape (`\e[y;xH`). `x="1" y="1"` is the very top-left of the slide pane.
257
+ - **Percent** (`x="50%"`, `y="100%"`) — resolves against the terminal's current width / height. Auto-adjusts when the pane is resized.
258
+ - Content is parsed inline, so all the usual styling works inside an `<at>` — `<size>`, `<color>`, `<font>`, `**bold**`, `*italic*`, etc.
259
+ - The block doesn't take up vertical space in the slide's layout — paragraphs around it render in their normal positions and the absolute placement layers on top. Useful for overlaying labels on a `<bg .../>` gradient or pinning annotations to specific cells.
260
+ - Out-of-range coordinates clamp into the visible area; missing / unparseable coordinates skip silently.
261
+
262
+ ### Image
263
+
264
+ Embed an image with the standard markdown form, or the `<img>` XML form when you want to absolute-position it. Both produce identical output — `<img>` just opens the door to extra attributes like `x` / `y`.
265
+
266
+ ```markdown
267
+ ![](doge.png){:relative_height="70"}
268
+ <img src="doge.png" relative_height="70"/>
269
+
270
+ <img src="doge.png" x="5" y="3" relative_height="40"/>
271
+ <img src="doge.png" x="50%" y="50%" relative_height="40"/>
272
+ ```
273
+
274
+ - `src` is required; `alt` and `title` are accepted and ignored at render time (kept for accessibility / future use).
275
+ - `relative_height="N"` caps the image at N % of the terminal height (default 70). Aspect ratio is preserved. `relative_width="N"` is the same for the horizontal dimension.
276
+ - `height="N%"` / `width="N%"` are short-form aliases for `relative_height` / `relative_width` (both forms — `<img>` and `![]{:...}` — accept the alias). An explicit `relative_*` on the same block wins; a non-`%` value (`height="40"`) is left alone since pixel units aren't supported.
277
+ - `x` / `y` (optional) anchor the image's top-left at an absolute cell. Same two forms as [`<at>`](#absolute-position-text):
278
+ - **Plain integer** — 1-based terminal cells.
279
+ - **Percent** — resolves against the terminal's current width / height.
280
+ - With `x` and `y` set, the image layers on top of the slide and contributes 0 to the layout flow — paragraphs around it render in their normal positions, exactly like `<at>`. Without `x` / `y`, the image stays horizontally centered and takes up its natural height in the flow.
281
+ - Rendering backend: Kitty Graphics Protocol on terminals that support it (PNG uploaded once and reused; JPG goes through `kitten icat`), Sixel as a fallback. Other terminals show nothing in place of the image.
282
+
221
283
  ### Comments
222
284
 
223
285
  ```markdown
@@ -285,6 +347,9 @@ background: # default slide background (Echoes OSC 7772)
285
347
  to: # gradient endpoint
286
348
  angle: # gradient angle in degrees
287
349
 
350
+ # rabbit: # opt into the 🐇 / 🐢 bottom progress indicator
351
+ # duration: "30m" # "1h30m", "1800s", or plain integer seconds; turtle hides when unset
352
+
288
353
  colors:
289
354
  code_bg: "313244"
290
355
  dim: "6c7086"
@@ -298,6 +363,7 @@ Notes:
298
363
  - **`font.family`** — applied to body text (terminal: via OSC 66 `f=`, requires Echoes; PDF: registered via fontconfig). Inline `<font face="...">` runs override it per-segment.
299
364
  - **`title`** — h1 typography. Each attribute is independent from `font`: `title.family` does **not** inherit `font.family`, `title.color` does **not** inherit `font.color`. `title.size` defaults to x-large (OSC 66 `s=4`). When `title.family` is proportional, every h1 OSC 66 sequence is emitted with `h=2` so a terminal that honors centered horizontal alignment ([Echoes](https://github.com/amatsuda/echoes)) keeps the title visually centered against its reserved cell block. h2–h6 stay body text.
300
365
  - **`background`** — the deck-wide default background. A per-slide `<bg .../>` directive overrides it for that slide. The Prawn fallback paints the PDF page in `background.color` when set; otherwise it leaves the page Prawn's default (white).
366
+ - **`rabbit`** — opt-in Rabbit-style bottom-row progress indicator. With the key absent, przn shows the simple `N / M` counter at the bottom-right. With the key present, the bottom row becomes: current slide # at the very left, total at the very right, 🐇 running between them tracking slide progress. Set `rabbit.duration` to also show 🐢 tracking elapsed time against the goal; without a duration the turtle stays hidden. Inside [Echoes](https://github.com/amatsuda/echoes) the emojis are emitted via OSC 7772 `;multicell` with `flip=h` so they face rightward; outside Echoes they fall back to standard OSC 66 and render unflipped (left-facing).
301
367
 
302
368
  ## License
303
369
 
data/Rakefile CHANGED
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bundler/gem_tasks"
4
- require "rake/testtask"
3
+ require 'bundler/gem_tasks'
4
+ require 'rake/testtask'
5
5
 
6
6
  Rake::TestTask.new(:test) do |t|
7
- t.libs << "test"
8
- t.libs << "lib"
9
- t.test_files = FileList["test/**/*_test.rb"]
7
+ t.libs << 'test'
8
+ t.libs << 'lib'
9
+ t.test_files = FileList['test/**/*_test.rb']
10
10
  end
11
11
 
12
12
  task default: :test
data/default_theme.yml CHANGED
@@ -33,3 +33,11 @@ colors:
33
33
  code_bg: "313244"
34
34
  dim: "6c7086"
35
35
  inline_code: "a6e3a1"
36
+
37
+ # Bottom-of-screen progress indicator (a nod to the Rabbit presentation tool).
38
+ # Without this key, przn shows a simple " N / M " counter at the bottom-right.
39
+ # To opt in: uncomment the block. The 🐇 rabbit anchors slide progress between
40
+ # the left (current) and right (total) numbers. Set `duration:` to enable the
41
+ # 🐢 turtle tracking elapsed time against the goal.
42
+ # rabbit:
43
+ # duration: # "30m", "1h30m", "1800s", or plain integer seconds
data/exe/przn CHANGED
@@ -7,7 +7,7 @@ require 'optparse'
7
7
 
8
8
  options = {}
9
9
  OptionParser.new do |opts|
10
- opts.banner = "Usage: przn [options] <presentation.md>"
10
+ opts.banner = 'Usage: przn [options] <presentation.md>'
11
11
  opts.on('--export [FORMAT]', 'Export to a format (pdf | prawn; default: pdf)') { |v|
12
12
  if v && v.end_with?('.md')
13
13
  ARGV.unshift(v)
@@ -19,12 +19,15 @@ OptionParser.new do |opts|
19
19
  opts.on('-o', '--output FILE', 'Output file path for export') { |v| options[:output] = v }
20
20
  opts.on('--theme FILE', 'Theme file (YAML)') { |v| options[:theme] = v }
21
21
  opts.on('--generate-theme', 'Generate theme.yml in current directory') { options[:generate_theme] = true }
22
+ opts.on('--present', 'Open the deck in presenter mode (auto-spawns an audience window on the secondary display)') { options[:present] = true }
23
+ opts.on('--audience', 'Run as the audience receiver (spawned by --present; expects --socket)') { options[:audience] = true }
24
+ opts.on('--socket PATH', 'Unix socket path used by --present/--audience to coordinate') { |v| options[:socket] = v }
22
25
  end.parse!
23
26
 
24
27
  if options[:generate_theme]
25
28
  content = File.read(Przn::Theme::DEFAULT_PATH).sub(/\A(#[^\n]*\n)+\n/, '')
26
29
  File.write('theme.yml', content)
27
- puts "Generated: theme.yml"
30
+ puts 'Generated: theme.yml'
28
31
  exit
29
32
  end
30
33
 
@@ -38,7 +41,7 @@ end
38
41
 
39
42
  file = ARGV[0]
40
43
  unless file
41
- $stderr.puts "Usage: przn [options] <presentation.md> [@N]"
44
+ $stderr.puts 'Usage: przn [options] <presentation.md> [@N]'
42
45
  exit 1
43
46
  end
44
47
 
@@ -50,11 +53,25 @@ end
50
53
 
51
54
  case options[:export]
52
55
  when 'pdf'
56
+ require_relative '../lib/przn/screenshot_pdf_exporter'
57
+
53
58
  output = options[:output] || File.basename(file, File.extname(file)) + '.pdf'
54
59
  Przn.export_pdf(file, output, theme: theme)
55
60
  when 'prawn'
61
+ require_relative '../lib/przn/prawn_pdf_exporter'
62
+
56
63
  output = options[:output] || File.basename(file, File.extname(file)) + '.pdf'
57
64
  Przn.export_pdf_prawn(file, output, theme: theme)
58
65
  else
59
- Przn.start(file, theme: theme, start_at: start_at).run
66
+ if options[:audience]
67
+ unless options[:socket]
68
+ $stderr.puts 'przn: --audience requires --socket PATH'
69
+ exit 1
70
+ end
71
+ Przn.audience(file, socket: options[:socket], theme: theme)
72
+ elsif options[:present]
73
+ Przn.present(file, theme: theme, theme_path: options[:theme]).run
74
+ else
75
+ Przn.start(file, theme: theme, start_at: start_at).run
76
+ end
60
77
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'socket'
5
+
6
+ module Przn
7
+ # Tiny line-delimited JSON protocol over a Unix-domain socket, joining the
8
+ # presenter and audience `przn` processes in extended-display mode.
9
+ #
10
+ # Messages currently exchanged:
11
+ # {"type": "ready"} audience -> presenter
12
+ # {"type": "goto", "index": N} presenter -> audience
13
+ # {"type": "quit"} presenter -> audience
14
+ module AudienceLink
15
+ module_function
16
+
17
+ # Audience-side: open a UNIXServer at `path`, wait for the presenter to
18
+ # connect, then yield each decoded message until EOF or {"type":"quit"}.
19
+ # The socket file is unlinked on exit.
20
+ def serve(path)
21
+ File.unlink(path) if File.exist?(path)
22
+ server = UNIXServer.new(path)
23
+ client = server.accept
24
+ send(client, {type: "ready"})
25
+ while (line = client.gets)
26
+ msg = JSON.parse(line.chomp, symbolize_names: true)
27
+ break if msg[:type] == "quit"
28
+ yield msg
29
+ end
30
+ rescue Errno::EPIPE, EOFError, IOError
31
+ # Presenter went away; let the caller exit cleanly.
32
+ ensure
33
+ client&.close
34
+ server&.close
35
+ File.unlink(path) if path && File.exist?(path)
36
+ end
37
+
38
+ # Presenter-side: connect to an audience socket at `path` and return a
39
+ # client object that responds to `#send` and `#close`. Caller drives the
40
+ # protocol from the controller.
41
+ def connect(path)
42
+ UNIXSocket.new(path)
43
+ end
44
+
45
+ def send(io, msg)
46
+ io.puts(JSON.generate(msg))
47
+ rescue Errno::EPIPE, IOError
48
+ # Other side hung up — caller decides whether to keep going.
49
+ end
50
+ end
51
+ end
@@ -2,14 +2,16 @@
2
2
 
3
3
  module Przn
4
4
  class Controller
5
- def initialize(presentation, terminal, renderer)
5
+ def initialize(presentation, terminal, renderer, audience_link: nil)
6
6
  @presentation = presentation
7
7
  @terminal = terminal
8
8
  @renderer = renderer
9
+ @audience_link = audience_link
9
10
  @preload_gen = 0
10
11
  end
11
12
 
12
13
  def run
14
+ @started_at = Time.now
13
15
  @terminal.enter_alt_screen
14
16
  @terminal.hide_cursor
15
17
  render_current
@@ -37,7 +39,12 @@ module Przn
37
39
  ensure
38
40
  @preload_gen += 1
39
41
  @preload_thread&.join
42
+ if @audience_link
43
+ AudienceLink.send(@audience_link, type: "quit")
44
+ @audience_link.close
45
+ end
40
46
  @terminal.write "\e]7772;bg-clear\a"
47
+ @terminal.write ImageUtil.kitty_clear_all if ImageUtil.kitty_terminal?
41
48
  @terminal.show_cursor
42
49
  @terminal.leave_alt_screen
43
50
  end
@@ -48,8 +55,15 @@ module Przn
48
55
  @renderer.render(
49
56
  @presentation.current_slide,
50
57
  current: @presentation.current,
51
- total: @presentation.total
58
+ total: @presentation.total,
59
+ started_at: @started_at
52
60
  )
61
+ if @audience_link
62
+ AudienceLink.send(@audience_link,
63
+ type: "goto",
64
+ index: @presentation.current,
65
+ started_at: @started_at.to_f)
66
+ end
53
67
  schedule_preload
54
68
  end
55
69
 
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
+ require 'json'
5
+ require 'timeout'
6
+
7
+ module Przn
8
+ # Thin wrappers around Echoes-specific OSC 7772 commands the presenter uses
9
+ # to set up extended-display mode. Other terminals ignore OSC 7772, so each
10
+ # method silently fails (returns nil / false) when not running inside Echoes.
11
+ module EchoesClient
12
+ OSC = "\e]7772"
13
+ BEL = "\a"
14
+ REPLY_TIMEOUT_S = 0.5
15
+
16
+ module_function
17
+
18
+ # Ask Echoes how many displays are attached and a tiny descriptor for each.
19
+ # Returns an Array of Hashes like [{index: 0, w: 1920, h: 1080}, ...] or
20
+ # nil when no reply arrives within the timeout (non-Echoes terminal, or
21
+ # an Echoes that doesn't speak this command yet).
22
+ def display_info(io_in: $stdin, io_out: $stdout)
23
+ io_out.write("#{OSC};display-info#{BEL}")
24
+ io_out.flush if io_out.respond_to?(:flush)
25
+ reply = read_osc_reply(io_in)
26
+ return nil unless reply
27
+ JSON.parse(reply, symbolize_names: true)
28
+ rescue JSON::ParserError
29
+ nil
30
+ end
31
+
32
+ # Open a new Echoes window on the given display, running `argv` (an
33
+ # Array of strings — argv[0] is the executable). `fullscreen:` is a hint.
34
+ # Returns true if the request was emitted; nothing in the protocol confirms
35
+ # success synchronously.
36
+ def open_window(display:, argv:, fullscreen: true, io_out: $stdout)
37
+ # `pack('m0')` is strict (no-newline) base64 — same as
38
+ # Base64.strict_encode64 but without pulling in the base64 stdlib,
39
+ # which is no longer a default gem in Ruby 3.4+.
40
+ payload = [JSON.generate(argv)].pack('m0')
41
+ args = "display=#{display}:program=#{payload}:fullscreen=#{fullscreen ? 'yes' : 'no'}"
42
+ io_out.write("#{OSC};open-window;#{args}#{BEL}")
43
+ io_out.flush if io_out.respond_to?(:flush)
44
+ true
45
+ end
46
+
47
+ # Read an OSC reply up to ST or BEL. Returns the payload string or nil on
48
+ # timeout. Echoes replies follow the same `\e]7772;...\a` shape it accepts.
49
+ #
50
+ # Stdin defaults to canonical (line-buffered) mode in a shell context, so
51
+ # `getc` would block waiting for a newline that an OSC reply never sends.
52
+ # Put the input in raw mode for the duration of the read; IO#raw saves and
53
+ # restores termios automatically.
54
+ def read_osc_reply(io_in)
55
+ if io_in.respond_to?(:raw) && io_in.respond_to?(:tty?) && io_in.tty?
56
+ io_in.raw { read_osc_reply_inner(io_in) }
57
+ else
58
+ read_osc_reply_inner(io_in)
59
+ end
60
+ end
61
+
62
+ def read_osc_reply_inner(io_in)
63
+ Timeout.timeout(REPLY_TIMEOUT_S) do
64
+ buf = +""
65
+ loop do
66
+ c = io_in.getc
67
+ return nil if c.nil?
68
+ break if c == BEL
69
+ if c == "\e"
70
+ nxt = io_in.getc
71
+ break if nxt == "\\"
72
+ buf << c << nxt
73
+ else
74
+ buf << c
75
+ end
76
+ end
77
+ buf.sub(/\A\e?\]?7772;[\w-]+;/, '')
78
+ end
79
+ rescue Timeout::Error
80
+ nil
81
+ end
82
+ end
83
+ end
@@ -42,14 +42,14 @@ module Przn
42
42
  # GIF
43
43
  f.seek(0)
44
44
  sig = f.read(6)
45
- if sig&.start_with?("GIF8")
45
+ if sig&.start_with?('GIF8')
46
46
  w = f.read(2)&.unpack1('v')
47
47
  h = f.read(2)&.unpack1('v')
48
48
  return [w, h] if w && h
49
49
  end
50
50
  end
51
51
  nil
52
- rescue
52
+ rescue StandardError
53
53
  nil
54
54
  end
55
55
 
@@ -62,7 +62,9 @@ module Przn
62
62
  end
63
63
 
64
64
  def kitty_terminal?
65
- ENV['TERM'] == 'xterm-kitty' || ENV['TERM_PROGRAM'] == 'kitty'
65
+ ENV['TERM'] == 'xterm-kitty' ||
66
+ ENV['TERM_PROGRAM'] == 'kitty' ||
67
+ ENV['TERM_PROGRAM'] == 'Echoes'
66
68
  end
67
69
 
68
70
  PNG_MAGIC = "\x89PNG\r\n\x1a\n".b.freeze
@@ -89,6 +91,16 @@ module Przn
89
91
  "\e_Ga=p,i=#{image_id},c=#{cols},r=#{rows},q=2\e\\"
90
92
  end
91
93
 
94
+ # Kitty Graphics Protocol: delete every placement and free the
95
+ # stored image data. Used on quit so previously-rendered images
96
+ # don't leak through onto the user's restored shell screen
97
+ # (placements aren't tied to the alt-screen buffer in most
98
+ # kitty-protocol implementations, so leaving the alt screen
99
+ # alone isn't enough to hide them). `q=2` suppresses the OK reply.
100
+ def kitty_clear_all
101
+ "\e_Ga=d,d=A,q=2\e\\"
102
+ end
103
+
92
104
  # Sixel via img2sixel
93
105
  def sixel_available?
94
106
  @sixel_available = system('command -v img2sixel > /dev/null 2>&1') if @sixel_available.nil?
@@ -5,19 +5,38 @@ module Przn
5
5
  HEADING_SCALES = {
6
6
  1 => 4,
7
7
  2 => 3,
8
- 3 => 2,
8
+ 3 => 2
9
9
  }.freeze
10
10
 
11
11
  module_function
12
12
 
13
- def sized(text, s:, h: nil, v: nil, n: nil, d: nil, f: nil)
13
+ # Emit sized multicell text. The `s/w/n/d/v/h` params are standard kitty
14
+ # OSC 66 (portable). The `f=` (font family) and `flip=` params are
15
+ # Echoes-only extensions — when one of them is set AND we're running
16
+ # inside Echoes, they ride on the private OSC 7772 ;multicell frame so
17
+ # that strict kitty terminals never see unknown params on OSC 66.
18
+ # Otherwise the extensions are silently dropped and we emit plain OSC
19
+ # 66, which renders without the flip / custom font on any kitty-
20
+ # compatible terminal (better than emitting an OSC 7772 frame the
21
+ # terminal would ignore entirely).
22
+ def sized(text, s:, h: nil, v: nil, n: nil, d: nil, f: nil, flip: nil)
14
23
  params = +"s=#{s}"
15
24
  params << ":n=#{n}" if n
16
25
  params << ":d=#{d}" if d
17
26
  params << ":h=#{h}" if h
18
27
  params << ":v=#{v}" if v
19
- params << ":f=#{f}" if f
20
- "\e]66;#{params};#{text}\a"
28
+
29
+ if (f || flip) && echoes?
30
+ params << ":f=#{f}" if f
31
+ params << ":flip=#{flip}" if flip
32
+ "\e]7772;multicell;#{params};#{text}\a"
33
+ else
34
+ "\e]66;#{params};#{text}\a"
35
+ end
36
+ end
37
+
38
+ def echoes?
39
+ ENV['TERM_PROGRAM'] == 'Echoes'
21
40
  end
22
41
 
23
42
  def heading(text, level:)